Skip to main content

Files Overview

The Patient Portal Files API lets the authenticated patient list, read, upload, rename, and soft-delete files (the /me/files resource). Every endpoint is self-only: the JWT subject is the only patient whose files are visible, and renames/deletes additionally require the patient to be the original uploader.

Uploads support two modes: case attachment (supply caseId) and general document (omit caseId). General documents are stored as UserDocument rows (type GENERAL) and appear in the admin panel under Documents → User Documents, but are not returned by GET /me/files.

Endpoints

#MethodPathPurpose
1GET/api/v1/users/me/filesList the patient's attachments (optionally filtered)
2GET/api/v1/users/me/files/:id/metadataGet a single attachment's metadata
3GET/api/v1/users/me/files/:id/downloadGet a short-lived signed download URL
4POST/api/v1/users/me/files/uploadUpload a base64-encoded file; linked to a case if caseId is provided, otherwise saved as a general document
5PATCH/api/v1/users/me/files/:id/metadataRename an attachment the patient uploaded
6DELETE/api/v1/users/me/files/:idSoft-delete an attachment the patient uploaded

Authentication

Every endpoint requires a successful /verify-otp exchange first.

HeaderRequiredDescription
cv-api-keyYesTenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR.
AuthorizationYesBearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401.
Content-TypeYes (POST/PATCH only)Must be application/json.

The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".

Permission Matrix

ActionAllowed when…
List own attachmentsAlways (filtered to the patient's cases in the calling org).
List a specific case's attachmentsThe case is owned by the patient (submitterId) and belongs to the calling org.
Read metadata for an attachmentThe attachment's case is owned by the patient and belongs to the calling org, and the attachment is not soft-deleted.
Download an attachmentSame as metadata read.
Upload (case attachment)caseId is provided, the case is owned by the patient (submitterId), and belongs to the calling org.
Upload (general document)caseId is omitted. No case ownership check; file is stored as a UserDocument (type GENERAL).
Rename an attachmentAll of the above plus the attachment's uploadedById equals the patient's userId.
Soft-delete an attachmentSame as rename — patient must be the original uploader.

The 404 is intentionally uniform: "doesn't exist", "deleted", "not yours", "wrong tenant", and "you didn't upload it" all collapse to the same response so attachment ids cannot be probed.

Common Response Envelope

List responses wrap the array in data.files:

{ "status": 200, "success": true, "data": { "files": [ "..." ] } }

Single-attachment responses (metadata, download, upload, update) place the payload directly under data:

{ "status": 200, "success": true, "data": { "...": "see Attachment Object Shapes" } }

Delete returns:

{ "status": 200, "success": true, "message": "File deleted successfully" }

Error responses follow:

{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }

Attachment Object Shapes

The shapes are not uniform across endpoints — pay attention to which fields appear where.

List item / Metadata / Update result

Used by GET /me/files, GET /me/files/:id/metadata, and PATCH /me/files/:id/metadata.

FieldTypeNotes
idstring (UUID)CaseAttachment.id.
fileNamestringOriginal or last-renamed file name.
isPHIbooleanDefaults to false on creation. Server-controlled.
isRestrictedbooleanDefaults to false. Server-controlled.
caseIdstring (UUID)The case the attachment belongs to.
uploadedBy{ id, firstName, lastName } | nullIncludes the uploader's User row (staff or patient).
createdAtISO-8601 datetimeServer-generated on create.

Download result

Used by GET /me/files/:id/download.

FieldTypeNotes
downloadUrlstringShort-lived signed URL into the GCS bucket (V4 read action).
fileNamestringThe current file name to use as the download filename.
expiresInnumber900 (seconds). Matches the 15-minute signed-URL TTL.

Upload result

Used by POST /me/files/upload.

FieldTypeNotes
idstring (UUID)CaseAttachment.id (case upload) or UserDocument.id (general upload).
fileNamestringThe name you supplied.
isPHIbooleanAlways false at creation.
isRestrictedbooleanAlways false at creation.
caseIdstring (UUID) | nullEchoed from the request, or null for general uploads.
createdAtISO-8601 datetimeServer-generated.
caution

The upload response does not include uploadedBy. If your client needs uploader info immediately after upload, follow up with GET /me/files/:id/metadata.

Allowed MIME Types

Uploads must declare a mimeType from this allowlist. Anything else fails Zod validation.

  • application/pdf
  • application/msword
  • text/csv
  • text/plain
  • image/jpeg
  • image/png
  • image/svg+xml
  • image/tiff
  • image/webp

Server-Side Behaviors and Defaults

  • Soft-delete only. DELETE flips isDeleted = true and writes a CaseActivity row of type DELETE_ATTACHMENT. The bucket object is retained.
  • Activity log. The patient's userId is recorded as actorId on the activity row.
  • isPHI / isRestricted are server-controlled. Patient uploads always start with isPHI = false and isRestricted = false. The patient portal does not expose a way to set or change these.
  • Sort order on list. createdAt descending (newest first).
  • Soft-deleted rows are excluded from every read endpoint.
  • Signed URLs are V4 read URLs with a 15-minute wall-clock TTL.
  • Object path layout. Case attachments: cases/<organizationId>/<caseId>/<attachmentId>/<fileName>. General documents: userDocs/<userId>/<documentId>/<fileName>.
  • No size cap on uploads is enforced at the application layer. App Engine / proxy limits apply transitively.
  • Atomic upload semantics. A failed bucket write is followed by a soft-delete of the just-created DB row; the patient sees 500 INTERNAL_ERROR and the row will not appear in subsequent listings.

Security Properties

  • Tenant isolation. Every file lookup checks case.organizationId === req.patientOrganization.id.
  • Ownership isolation. Every file lookup checks case.submitterId === req.patientUser.id.
  • Uploader-self gate on mutation. Rename and delete additionally require uploadedById === userId. Staff-uploaded files are read-only to the patient.
  • Uniform 404. Not-found, soft-deleted, wrong-tenant, wrong-owner, and (for mutations) wrong-uploader all return the same 404 VALIDATION_ERROR "File not found".
  • Token type pinned. Only JWTs with type: 'patient-portal' reach the handler.
  • Cross-tenant defense. The JWT's organizationId is verified against the cv-api-key-resolved org on every call.
  • Signed-URL expiry. Download URLs are short-lived (15 min); expiresIn: 900 advertises the same window.
  • Allowlisted MIME types. Only the nine types above are accepted on upload.

Integrator Guidance

  • Refresh proactively. Refresh the access token via /refresh-token before the 15-minute expiry.
  • Listing strategy. Call GET /me/files once per session and refresh after upload/rename/delete. Use ?caseId= when surfacing files within a single-case view.
  • Filtering. Use ?type=phi or ?type=general to split the patient view; omit to show everything.
  • Upload payload. Send the file as base64 in data. Chunked / multipart upload is not offered.
  • General uploads are not listed by GET /me/files. Omitting caseId creates a UserDocument, which is visible in the admin panel under Documents → User Documents but outside the scope of the /me/files list endpoint.
  • Renames are display-only. They change the visible label and the next download URL's filename, but do not relocate the underlying bucket object.
  • Patient can only rename/delete their own uploads. Surface staff-uploaded attachments as read-only in the UI to avoid 404 surprises.
  • Download UX. Re-fetch :id/download on demand rather than caching the signed URL.
  • Treat 404 as "no permission, may or may not exist". Do not display id-specific debug text.